"""Run scheduled tasks in a crontab style."""
import threading
import time

from crontab import CronTab
import prometheus_client as prometheus

from cki_lib import misc
from cki_lib.logger import get_logger

LOGGER = get_logger(__name__)
METRIC_CRONJOB_EXECUTED = prometheus.Counter(
    'cki_cronjob_executed',
    'Number of times a cronjob was executed',
    ['name'],

)
METRIC_CRONJOB_DURATION = prometheus.Summary(
    'cki_cronjob_duration_seconds',
    'Time spent executing a cronjob',
    ['name'],
)


def check_and_run(jobs):
    """Loop over all jobs and call check_and_run on each one."""
    for job in jobs:
        job.check_and_run()


def run(jobs, loop_period_s=5):
    """
    Run the cronjobs in a loop.

    Endlessly loop over the tasks and check if any of them needs to be run.

    Arguments:
        - jobs: List of instances of CronJob classes.
        - loop_period_s: Time between checks.
    """
    while True:
        check_and_run(jobs)
        time.sleep(loop_period_s)


class CronJob:
    """Base class to execute scheduled tasks.

    Needs to be extended to create a new cronjob.

    To modify the cronjob behaviour, the following attributes need to be modified:
        - schedule: Crontab style definition of the task.
        - back_off_limit: Number of times a cronjob can be found running when scheduled
                          before logging an error. (Default: 0)
        - run: Entrypoint of the task. Will be executed every time the cronjob is called.
    """

    schedule = '*/5 * * * *'
    back_off_limit = 0

    def __init__(self):
        """Initialize."""
        self._last_run_datetime = None
        self._running = threading.Event()
        self._back_off_counter = 0
        LOGGER.info('Initialized %s', self.__class__.__name__)

    def run(self, *, last_run_datetime=None):
        """
        Entrypoint for the task.

        This method is called every time the job is run and should
        perform all the work.
        """
        raise NotImplementedError

    def _run(self, last_run_datetime):
        """Run and clear the _running flag."""
        LOGGER.info('Running %s', type(self).__name__)
        METRIC_CRONJOB_EXECUTED.labels(self.__class__.__name__).inc()
        try:
            with METRIC_CRONJOB_DURATION.labels(self.__class__.__name__).time():
                self.run(last_run_datetime=last_run_datetime)
        finally:
            self._running.clear()
        LOGGER.debug('Finished running %s', type(self).__name__)

    def _next(self):
        return CronTab(self.schedule).next(now=self._last_run_datetime, return_datetime=True)

    def check_and_run(self) -> threading.Thread | None:
        """Check the time since the last run and, if necessary, call run().

        Return:
            In case run() was called, returns the Thread instance.
        """
        now = misc.now_tz_utc()
        runner = None

        if not self._last_run_datetime or self._next() <= now:
            last_run_datetime, self._last_run_datetime = self._last_run_datetime, now

            if self._running.is_set():
                LOGGER.info("Job '%s' is scheduled to run but it's still running.",
                            type(self).__name__)
                self._back_off_counter += 1
                if self._back_off_counter > self.back_off_limit:
                    LOGGER.error("BackOff limit exceeded for job '%s' (current: %d, limit: %d)",
                                 type(self).__name__, self._back_off_counter, self.back_off_limit)

                return None

            self._running.set()
            self._back_off_counter = 0
            runner = threading.Thread(target=self._run, args=[last_run_datetime], daemon=True)
            runner.start()

        return runner
